package com.yifeng.repo.controller.excel.loader;

import com.alibaba.excel.support.ExcelTypeEnum;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.gomcarter.frameworks.base.common.AssertUtils;
import com.gomcarter.frameworks.base.exception.CustomException;
import com.google.common.base.Strings;
import com.talkyun.utils.MD5;
import com.yifeng.repo.base.utils.common.BaseUtil;
import com.yifeng.repo.base.utils.file.FileHelper;
import com.yifeng.repo.base.utils.file.ZipHelper;
import com.yifeng.repo.controller.excel.configure.ExcelLoaderProperties;
import com.yifeng.repo.controller.excel.loader.biz.BizExportAction;
import com.yifeng.repo.controller.excel.loader.biz.BizImportAction;
import com.yifeng.repo.controller.excel.loader.dto.DataTaskDto;
import com.yifeng.repo.controller.excel.loader.dto.DataTaskTypeEnum;
import com.yifeng.repo.controller.excel.loader.task.DataExportTask;
import com.yifeng.repo.controller.excel.loader.task.DataImportTask;
import com.yifeng.repo.controller.excel.loader.task.handler.ExcelFileHandler;
import com.yifeng.repo.controller.excel.loader.worker.ExcelLoaderOssWorker;
import com.yifeng.repo.controller.excel.loader.worker.ExcelLoaderRedisWorker;
import com.yifeng.repo.controller.excel.loader.worker.LsdWorker;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Created by daibing on 2022/12/29.
 */
@Slf4j
public class ExcelLoaderManager {
    private final ExcelLoaderProperties properties;
    private final ExcelLoaderRedisWorker redisWorker;
    private final ExcelLoaderOssWorker ossWorker;
    private final LsdWorker lsdWorker;

    public ExcelLoaderManager(ExcelLoaderProperties properties, ExcelLoaderRedisWorker redisWorker, ExcelLoaderOssWorker ossWorker, LsdWorker lsdWorker) {
        this.properties = properties;
        this.redisWorker = redisWorker;
        this.ossWorker = ossWorker;
        this.lsdWorker = lsdWorker;
    }

    /**
     * excel-loader组件核心逻辑是简化控制逻辑，就是做减法：
     * 1、导出excel获取源数据需要保存筛选条件才可以做到无状态，当前觉得成本较大，引起代码复杂度增加
     * 2、当前所有导入&导出任务都需要绑定在初始化的节点上执行完成，节点重启后需要重新触发导入&导出操作
     * 3、引入组件后如果没有启动导入&导出动作就不起线程，避免给业务项目带来负担，当然带来的问题就是不支持任务排队，当前通过任务并发数量限制来控制。
     */

    public void init() {
        log.info("yfcloud controller excel-loader init success: OWNER_ID={}, OWNER_NAME={}", BaseUtil.OWNER_ID, BaseUtil.OWNER_NAME);
    }

    public void destroy() {
        log.info("yfcloud controller excel-loader destroy success: OWNER_ID={}, OWNER_NAME={}", BaseUtil.OWNER_ID, BaseUtil.OWNER_NAME);
    }

    /**
     * 列出数据任务requestKey: H5 -> excel loader controller -> redis
     */
    public List<String> listDataTaskRequestKey(DataTaskTypeEnum type) {
        return redisWorker.listDataTaskRequestKey(type);
    }

    /**
     * 查询数据任务: H5 -> excel loader controller -> redis
     *
     * @param requestKey 请求唯一值
     */
    public DataTaskDto getDataTask(String requestKey, DataTaskTypeEnum type) {
        return redisWorker.getDataTask(requestKey, type);
    }

    /**
     * 清除运行数：导入导出的运行数redis缓存
     */
    public Long clearRunningNumber() {
        return redisWorker.clearRunningNumber();
    }

    /**
     * 做excel导入: H5 -> Biz backend server -> excel loader controller
     *
     * @param requestKey      请求唯一值，用作幂等避免重复操作，以及查询任务信息
     *                        比如导入文件名+操作用户名+时间区间地板值的md5值
     *                        long periodFloorBound = System.currentTimeMillis() / (5 * 60 * 1000L)
     * @param bizImportAction 导入处理器
     */
    public DataTaskDto doImport(String requestKey, BizImportAction bizImportAction) {
        return doImport(IdWorker.getId(), requestKey, bizImportAction);
    }

    /**
     * 做excel导入: H5 -> Biz backend server -> excel loader controller
     *
     * @param taskId          业务Id，仅用做传递关联关系，回传给业务项目做业务主键
     * @param requestKey      请求唯一值，用作幂等避免重复操作，以及查询任务信息
     *                        比如导入文件名+操作用户名+时间区间地板值的md5值
     *                        long periodFloorBound = System.currentTimeMillis() / (5 * 60 * 1000L)
     * @param bizImportAction 导入处理器
     */
    public DataTaskDto doImport(Long taskId, String requestKey, BizImportAction bizImportAction) {
        // 1. 检查redis：存在该任务并且任务运行完成或者有效执行中直接返回数据任务
        DataTaskDto dataTaskDto = redisWorker.getDataTask(requestKey, DataTaskTypeEnum.IMPORT);
        if (dataTaskDto != null && this.isValidTask(dataTaskDto)) {
            return dataTaskDto;
        }

        // 2. 检查redis不存在该任务，或者任务已经中断了，都需要先抢占锁确保唯一执行任务
        boolean locked = redisWorker.lock(requestKey, BaseUtil.OWNER_ID.toString(), 2 * 60 * 1000L);
        if (!locked) {
            throw new CustomException(requestKey + "存在并发请求重复发起导入！");
        }

        // 3. 抢占redis后再检查一次redis：检查发现存在该任务属于最新写入的任务，可以直接返回数据任务
        dataTaskDto = redisWorker.getDataTask(requestKey, DataTaskTypeEnum.IMPORT);
        if (dataTaskDto != null && this.isValidTask(dataTaskDto)) {
            return dataTaskDto;
        }

        // 4. 抢占锁成功后，检查该应用当前正在运行中任务数量，未超过限额就启动新任务
        long runningNumber = redisWorker.getRunningNumber();
        if (runningNumber >= properties.getRunningLimitNumber()) {
            throw new CustomException(String.format("%s 任务需要稍候再试，当前正在运行中的任务数量%s 已经超出该应用限额 %s",
                    requestKey, runningNumber, properties.getRunningLimitNumber()));
        }

        // 5. 抢占锁成功后，开始启动新任务, 并且写入redis
        dataTaskDto = this.buildDataTask(dataTaskDto, taskId, requestKey, DataTaskTypeEnum.IMPORT, bizImportAction.dataFileUrl());
        new DataImportTask(dataTaskDto, bizImportAction, bizImportAction.getTaskConf(properties), redisWorker, ossWorker, lsdWorker).startup();
        String content = redisWorker.saveDataTask(dataTaskDto);
        log.info("import task {} create ok, content={}", requestKey, content);
        return dataTaskDto;
    }

    /**
     * 做excel导出: H5 -> Biz backend server -> excel loader controller
     *
     * @param requestKey      请求唯一值，用作幂等避免重复操作，以及查询任务信息
     *                        比如导出筛选参数的md5值
     * @param bizExportAction 导出处理器
     */
    public DataTaskDto doExport(String requestKey, BizExportAction bizExportAction) {
        return doExport(IdWorker.getId(), requestKey, bizExportAction);
    }

    /**
     * 做excel导出: H5 -> Biz backend server -> excel loader controller
     *
     * @param taskId          业务Id，仅用做传递关联关系，回传给业务项目做业务主键
     * @param requestKey      请求唯一值，用作幂等避免重复操作，以及查询任务信息
     *                        比如导出筛选参数的md5值
     * @param bizExportAction 导出处理器
     */
    public DataTaskDto doExport(Long taskId, String requestKey, BizExportAction bizExportAction) {
        // 1. 检查redis：存在该任务并且任务运行完成或者有效执行中直接返回数据任务
        DataTaskDto dataTaskDto = redisWorker.getDataTask(requestKey, DataTaskTypeEnum.EXPORT);
        if (dataTaskDto != null && this.isValidTask(dataTaskDto)) {
            return dataTaskDto;
        }

        // 2. 检查redis不存在该任务，或者任务已经中断了，都需要先抢占锁确保唯一执行任务
        boolean locked = redisWorker.lock(requestKey, BaseUtil.OWNER_ID.toString(), 2 * 60 * 1000L);
        if (!locked) {
            throw new CustomException(requestKey + "存在并发请求重复发起导出！");
        }

        // 3. 抢占redis后再检查一次redis：检查发现存在该任务属于最新写入的任务，可以直接返回数据任务
        dataTaskDto = redisWorker.getDataTask(requestKey, DataTaskTypeEnum.EXPORT);
        if (dataTaskDto != null && this.isValidTask(dataTaskDto)) {
            return dataTaskDto;
        }

        // 4. 抢占锁成功后，检查该应用当前正在运行中任务数量，未超过限额就启动新任务，注意这里不是精确控制，并发请求的时候会超出配额。
        long runningNumber = redisWorker.getRunningNumber();
        if (runningNumber >= properties.getRunningLimitNumber()) {
            throw new CustomException(String.format("%s 任务需要稍候再试，当前正在运行中的任务数量%s 已经超出该应用限额 %s",
                    requestKey, runningNumber, properties.getRunningLimitNumber()));
        }

        // 5. 抢占锁成功后，开始启动新任务, 并且写入redis
        dataTaskDto = this.buildDataTask(dataTaskDto, taskId, requestKey, DataTaskTypeEnum.EXPORT, bizExportAction.templateUrl());
        new DataExportTask(dataTaskDto, bizExportAction, bizExportAction.getTaskConf(properties), redisWorker, ossWorker).startup();
        String content = redisWorker.saveDataTask(dataTaskDto);
        log.info("export task {} create ok, content={}", requestKey, content);
        return dataTaskDto;
    }

    /**
     * 下载压缩包文件，并且解压缩
     *
     * @param dataFileZipUrl 数据压缩包文件地址
     * @return 压缩包解压后的文件列表 【注意：业务调用方自己负责清理这些解压后的文件】
     */
    public List<File> doDownloadAndUnzip(String dataFileZipUrl) {
        // 1. 检查入参
        if (Strings.isNullOrEmpty(dataFileZipUrl)) {
            return Collections.emptyList();
        }

        // 2. 转存文件：支持url地址和本地文件地址
        String zipFileName = MD5.md5(dataFileZipUrl);
        File dumpZipFile = new File(ExcelFileHandler.getDataDumpZipFilePath(zipFileName, DataTaskTypeEnum.IMPORT));
        try {
            if (ExcelFileHandler.isHttpFile(dataFileZipUrl)) {
                FileHelper.downloadFile(dataFileZipUrl, dumpZipFile, properties.getImportExcelDownTaskNumber());
            } else {
                FileHelper.copyFile(new File(dataFileZipUrl), dumpZipFile);
            }
        } catch (IOException e) {
            throw new CustomException("下载压缩包转存文件失败: " + dataFileZipUrl + ", " + dumpZipFile.getPath());
        }

        // 3. 解压缩，清理文件
        File unZipFolder = new File(dumpZipFile.getAbsolutePath().replace(".zip", ""));
        ZipHelper.decompress(dumpZipFile, unZipFolder);
        dumpZipFile.delete();
        return FileHelper.listSubFile(unZipFolder, true);
    }

    /**
     * 对多个导出结果文件做zip压缩和上传oss
     *
     * @param exportRequestKeys 已经完成导出的请求key列表
     * @param resultFileUri     获取导出excel的数据结果文件-上传oss的文件名
     * @return 上传oss的url地址
     */
    public String doZipAndUpload(List<String> exportRequestKeys, String resultFileUri) {
        // 检查入参
        if (exportRequestKeys.isEmpty() || Strings.isNullOrEmpty(resultFileUri)) {
            return null;
        }

        // 找到请求key对应的导出结果文件
        List<File> exportResultFiles = exportRequestKeys.stream()
                .map(requestKey -> {
                    File file = new File(ExcelFileHandler.getDataDumpFilePath(requestKey, DataTaskTypeEnum.EXPORT));
                    AssertUtils.isTrue(file.exists(), "requestKey=" + requestKey + ", 文件不存在=" + file.getAbsolutePath());
                    return file;
                })
                .collect(Collectors.toList());

        // 压缩并且上传
        String zipSuffix = String.format("-%s.zip", exportResultFiles.size());
        File zipFile = new File(exportResultFiles.get(0).getAbsolutePath().replace(ExcelTypeEnum.XLSX.getValue(), zipSuffix));
        ZipHelper.compress(exportResultFiles, zipFile);
        String dataResultUrl = ossWorker.uploadFile(resultFileUri, zipFile.getAbsolutePath());

        // 清理文件
        exportResultFiles.forEach(File::delete);
        zipFile.delete();
        return dataResultUrl;
    }

    public String doZipAndUpload(List<String> exportRequestKeys) {
        if (exportRequestKeys.isEmpty()) {
            return null;
        }
        String zipSuffix = String.format("-%s.zip", exportRequestKeys.size());
        String resultFileUri = "/excel/" + DataTaskTypeEnum.EXPORT.name().toLowerCase() + "/" + exportRequestKeys.get(0) + zipSuffix;
        return doZipAndUpload(exportRequestKeys, resultFileUri);
    }

    /**
     * 支持指定zip包内部文件名
     */
    public String doZipAndUpload(Map<String, String> exportRequestKey2FileName, String resultFileUri) {
        // 检查入参
        if (exportRequestKey2FileName.isEmpty() || Strings.isNullOrEmpty(resultFileUri)) {
            return null;
        }

        // 找到请求key对应的导出结果文件
        List<File> exportResultFiles = exportRequestKey2FileName.keySet().stream()
                .map(requestKey -> {
                    File file = new File(ExcelFileHandler.getDataDumpFilePath(requestKey, DataTaskTypeEnum.EXPORT));
                    AssertUtils.isTrue(file.exists(), "requestKey=" + requestKey + ", 文件不存在=" + file.getAbsolutePath());
                    File newFile = new File(ExcelFileHandler.getDataDumpFilePath(exportRequestKey2FileName.get(requestKey), DataTaskTypeEnum.EXPORT));
                    try {
                        Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
                    } catch (IOException e) {
                        throw new CustomException("重命名文件失败：from " + file.getName() + " to " + newFile.getName());
                    }
                    return newFile;
                })
                .collect(Collectors.toList());

        // 压缩并且上传
        String zipSuffix = String.format("-%s.zip", exportResultFiles.size());
        File zipFile = new File(exportResultFiles.get(0).getAbsolutePath().replace(ExcelTypeEnum.XLSX.getValue(), zipSuffix));
        ZipHelper.compress(exportResultFiles, zipFile);
        String dataResultUrl = ossWorker.uploadFile(resultFileUri, zipFile.getAbsolutePath());

        // 清理文件
        exportResultFiles.forEach(File::delete);
        zipFile.delete();
        return dataResultUrl;
    }

    private boolean isValidTask(DataTaskDto dataTaskDto) {
        // 任务已经完成
        if (dataTaskDto.isTotalFinished()) {
            return true;
        }
        // 任务在刷新周期内，也就是有效执行中
        return dataTaskDto.getRefreshTime().getTime() + properties.getLiveProbeIntervalMillis() >= System.currentTimeMillis();
    }

    /**
     * 构造数据任务对象: 已经在redis存在的任务就直接基于redis数据来更新信息,不存在redis的任务需要全新构建
     */
    private DataTaskDto buildDataTask(DataTaskDto dataTaskDto, Long taskId, String requestKey, DataTaskTypeEnum type, String dataFileUrl) {
        Date now = new Date();
        // 构造数据任务对象, 新任务需要先设置基本信息字段.
        if (dataTaskDto == null) {
            dataTaskDto = new DataTaskDto();
            dataTaskDto.setTaskId(taskId);
            dataTaskDto.setRequestKey(requestKey);
            dataTaskDto.setType(type);
            dataTaskDto.setDataFileUrl(dataFileUrl);
            dataTaskDto.setDataDumpFilePath(ExcelFileHandler.getDataDumpFilePath(requestKey, type));
            dataTaskDto.setFinishedOffsetId("0");
            dataTaskDto.setCreateTime(now);
        }
        // 构造数据任务对象, 中断的任务仅需要更新owner相关字段.
        dataTaskDto.setOwnerId(BaseUtil.OWNER_ID);
        dataTaskDto.setOwnerName(BaseUtil.OWNER_NAME);
        dataTaskDto.setOwnerTime(now);
        dataTaskDto.setRefreshTime(now);
        dataTaskDto.setModifyTime(now);
        return dataTaskDto;
    }

}
